Skip to content

feat(ai): tier-3 supervised/four-eyes approval split — backend core - #3175

Open
ToddHebebrand wants to merge 17 commits into
mainfrom
ToddHebebrand/tier3-supervised-four-eyes
Open

feat(ai): tier-3 supervised/four-eyes approval split — backend core#3175
ToddHebebrand wants to merge 17 commits into
mainfrom
ToddHebebrand/tier3-supervised-four-eyes

Conversation

@ToddHebebrand

Copy link
Copy Markdown
Collaborator

What

Splits AI Tier-3 approvals into two scopes (spec: docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md, in this PR):

  • supervised (default): the AI is the actor, the requesting human is the approver — plain Approve click in chat, gated on the requester's own RBAC for the underlying action. No second human. Routine device work (execute_command, run_script, file/registry/service/process ops, patch install) lands here.
  • four_eyes (explicit list, exhaustiveness contract-tested): a second human holding approvals:decide decides — financial issuance/payment, tenant-shape changes, M365/Google identity mutations, restore/DR, containment release, computer control. Chat approval window raised 5→60 min for these; the durable release worker executes late approvals under a 10-min release lease.

Motivation: for orgs with ≥2 admins and no mobile app (all self-hosters — the app isn't distributable yet), every Tier-3 action fanned out four-eyes with push-only notification and a 5-minute window → guaranteed expiry. Reported by a self-hosted partner 2026-08-05.

Also fixes (found during design review + implementation)

  • Approver resolver counted disabled/invited users as eligible — inflating four-eyes and suppressing the sole-operator fallback (PAM twin filed as PAM: resolveElevationApprovers counts disabled/invited users as eligible approvers #3174).
  • Decide endpoint was non-atomic: approval row committed, then intent transition/outbox could fail silently with HTTP 200 — now one transaction, intent-first lock order (removes a decide/reaper/report-suspicious deadlock class), 500 retryable on fault. report-suspicious got the same treatment.
  • TOCTOU on approved content: four-eyes intents pin an effect digest (script body, quote/invoice revision, org status) revalidated at release — including the inline chat release path — failing with content_changed on drift.
  • Expiry split: approval_expires_at (pending) + release_by lease (approved) — an approval at 59:59 no longer gets reaped before the worker claims it; legacy-row COALESCE fallbacks for rolling upgrades.
  • /pending live authorization: demoted approvers stop seeing request arguments; keyset pagination + GET /pending/count; transport-neutral /api/v1/approvals mount (mobile alias kept, response shape backward-compatible with additive nextCursor).
  • Partner enforcing assurance floors still apply to supervised decides (WebAuthn step-up honored; plain click only when not enforcing).

Reviewer callouts

  • SIEM/audit shape: action_intent.self_approved_sole_operator now fires only for genuine four-eyes sole-operator self-approvals; supervised decides audit approvalMethod: 'supervised_self'. approved flag semantics per [API] Decide: should read-only execute_command types auto-execute under the default per_step approval mode? #3130 unchanged.
  • Behavior change: decide fan-in fault now returns 500 retryable (was 200 with half-applied state); the old integration test asserting that was inverted deliberately.
  • Migration: 2026-08-14-intent-approval-scope-and-deadlines.sql (forward-dated to sort after existing 2026-08-13; idempotent; extends the immutability trigger). All five new columns registered in the export policy.
  • MCP unchanged: both scopes still fail closed (MCP_APPROVAL_REQUIRED).
  • Web approvals inbox + notification events are Plan 2 (chat SSE already carries approvalScope; the in-chat supervised card is actionable today via the existing sole-operator card path).
  • Conservative classifications parked for follow-up: 11 Google helpdesk-ish tools + request_elevation are four_eyes pending explicit demotion decisions.

Testing

Full unit suite (20k+ tests), RLS suite, integration suite (incl. new intentSupervisedFourEyes, approvalsDecideSupervised, inverted atomicity test, reaper legacy-row cases) green locally; db:check-drift clean. Contract tests: classification exhaustiveness (every effective-tier-3 surface explicitly scoped), durable-executable (four-eyes ⊆ headless-executable, input-aware tools included), export policy.

🤖 Generated with Claude Code

Todd Hebebrand and others added 17 commits August 6, 2026 00:06
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… scopes

Adds TIER3_FOUR_EYES_ACTIONS/TOOLS and TIER3_SUPERVISED_ACTIONS/TOOLS plus
resolveApprovalScope() per the 2026-08-05 tier3-supervised-four-eyes design
(§3.1/§3.2): within tier 3, four_eyes requires a second approvals:decide
holder; everything else is supervised (requester self-approves). checkGuardrails
now surfaces approvalScope on both tier-3 return branches.

Exports getAllRegisteredToolNames() from aiTools.ts (core registry + M365/Google
tier maps) so the exhaustiveness contract test can walk every real tool.

s1_threat_action gets a new per-action split in TIER3_ACTIONS (kill/quarantine
supervised, rollback four_eyes) since its action enum is a real discriminator,
unlike s1_isolate_device's boolean isolate flag which stays whole-tool
supervised per spec §3.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st columns

Adds approval_scope/classification_version (immutable tier-3 classification
from checkGuardrails), approval_expires_at/release_by (split pending-approval
vs execution-lease deadlines), and effect_digest (four_eyes content-pinning
hash) to action_intents, per the tier3-supervised-four-eyes-split design.
Extends action_intents_immutable_trg to cover the three immutable columns;
release_by/approval_expires_at stay mutable for lifecycle stamping. Registers
the new columns in the export-policy allowlist and updates the schema
contract test's column-list assertion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
resolveIntentApprovers counted disabled/invited users as eligible
Tier-3 action-intent approvers on both the org-member and partner-axis
candidate queries, inflating the four-eyes approver set and wrongly
suppressing the sole-operator fallback. Join `users` and gate on
status='active' on both queries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t deadlines

Task 4 of the tier3-supervised-four-eyes backend plan. createActionIntent now
reads checkGuardrails' approvalScope, stamps approvalScope/classificationVersion
on the intent, and short-circuits supervised intents to a single requester-owned
approval row (no approvals:decide required, no push). Four-eyes chat deadlines
extend to 60 minutes; supervised keeps the existing 5-minute window. Legacy
expiresAt is dual-written alongside the new approvalExpiresAt column for
rolling-upgrade compat (removal tracked as Plan 3 cleanup).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract insertSingleApproverRow(userId) helper shared by the supervised
short-circuit and the four_eyes sole-operator branch — both previously
had byte-identical 8-line blocks to insert one approval_requests row
and derive approvalRequestIds/requesterApprovalRequestId/fanOutUserIds.
Behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pending_approval intents now expire on approval_expires_at while approved
intents expire on release_by (falling back to expires_at for legacy rows),
in both the expiry reaper and the release worker's claim CAS. The approve
fan-in stamps release_by = now() + RELEASE_LEASE_MS (10m) on an approval
win, so an intent approved just before approval_expires_at gets a fresh
lease instead of being reaped for a deadline that no longer governs it
(the "59:59 trap").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tier-3 SUPERVISED intents let the requester self-decide with no WebAuthn
assertion, gated on a live re-check of the underlying tool's RBAC
permission (not approvals:decide) via the same buildAuthContextForIntent +
checkToolPermission pair the release worker uses. A non-requester is
refused (403 not_requester) even if they hold approvals:decide.

The whole decide write — approval-row CAS, ai_tool_executions mirror, and
the action-intents fan-in (intent CAS + release_by + sibling expiry +
intent_approved outbox insert) — now commits as ONE system-scoped
transaction, so a fault anywhere inside rolls everything back and the
caller gets a retryable 500 instead of a half-applied decision (approval
decided with no intent/outbox follow-through). report-suspicious's
intent-rejection block gets the same fail-loud treatment instead of
swallowing the error.

Extends aiAgentSdk.ts's ApprovalMethod audit union with 'supervised_self'
so the postToolUse audit event distinguishes a supervised self-decide from
a four_eyes approval via the approvals surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, enforcing floor

Fix round 1 on the supervised plain-decide + atomic-decide-transaction work:

1. report-suspicious: fold the approval-row 'reported' flip into the SAME
   transaction as the intent CAS + sibling expiry. Previously the flip
   committed separately BEFORE that transaction opened, so a throw inside it
   left the row 'reported' with the intent permanently stranded
   pending_approval (a retry's pre-fetch saw status!=='pending' and never
   re-entered the fan-in). A rollback now restores 'pending' too.

2. Lock-order inversion: both the decide-write transaction and
   report-suspicious's intent-reject transaction now take a
   `SELECT ... FOR UPDATE` lock on the intent row FIRST, before touching
   approval_requests — matching report-suspicious/intentExpiryReaper's
   existing order. The decide handler previously locked its own approval row
   first and the intent second, the opposite order, which could deadlock
   (Postgres 40P01) against a concurrent report-suspicious/reaper transaction.

3. The ai_tool_executions mirror (system-scoped, no RLS) now carries an
   explicit `exists(...)` tenant/linkage guard in its UPDATE's WHERE clause —
   verifying the execution's ai_sessions row belongs to the same user as the
   approval_requests row being decided — instead of relying solely on the
   (already-trusted, but app-layer-only) executionId FK value.

4. New `approvalsDecideSupervised.integration.test.ts`: drives the real
   approve/deny route against genuine role/permission state (not the mocked
   buildAuthContextForIntent/checkToolPermission the unit suite stands in
   for) — happy path (200, intent approved, outbox written) and a
   revoked-permission path (403, nothing written).

5. Adjudicated requirement: a supervised decide now checks whether the
   partner's authenticator policy is actively enforcing before skipping the
   assurance ladder. When enforcing, the requester goes through the same
   ladder a four_eyes sole-operator self-approve does (WebAuthn L3 satisfies
   it); non-enforcing partners keep the unchanged plain-click path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds effectDigest.ts (SHA-256 over resolved tool-specific target content:
run_script body, quote send revision, invoice/contract state, org status).
createIntent computes and stores it for four_eyes intents only, inside the
creation transaction; the release worker recomputes it immediately before
execution and CASes to failed:content_changed on a mismatch, closing the
TOCTOU gap where an approver signs off on a reference whose target mutates
during the (up to 60-minute) four_eyes approval window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t-neutral mount

GET /pending now joins action_intents and drops rows whose intent is no
longer pending_approval, or whose caller no longer qualifies live
(four_eyes: current approvals:decide + org access; supervised: still the
requester) — a demoted approver stops seeing (and count-ing) a row it can
no longer decide. Adds keyset (createdAt, id) pagination (limit capped at
50, opaque cursor, additive nextCursor field) and a new GET /pending/count
using the identical filter set. Both routes are now also mounted
transport-neutrally at /api/v1/approvals, alongside the existing
/api/v1/mobile/approvals alias, with the same mobileDeviceBlockedMiddleware
so a blocked device can't dodge the check via the new prefix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…le four-eyes contract

- resolveApprovalScope now takes `input` and resolves manage_organizations:
  update_org (four_eyes only when `status` is present, else supervised) and
  s1_isolate_device (four_eyes only on isolate:false containment release,
  else supervised) from argument content instead of a static tool/action
  table. Both are exempted from the contract test's static exactly-one-scope
  invariant via new TIER3_INPUT_AWARE_ACTIONS/TIER3_INPUT_AWARE_TOOLS sets
  and covered by dedicated both-branches tests.
- aiAgentSdk's chat SSE approval_required event for the tier-3 durable-intent
  path now carries approvalScope alongside the existing selfApprovalRequestId,
  so the web approval card can distinguish a supervised self-approve from a
  four_eyes sole-operator self-approve (AiStreamEvent type updated in
  packages/shared). Push dispatch is unchanged: it was already fully owned
  and gated to four_eyes inside intentService.ts's createActionIntent; the
  only push call site in aiAgentSdk.ts is the unrelated Tier-2 legacy
  per_step bridge.
- New durable-executable contract test
  (intentReleaseWorker.durable.contract.test.ts) asserts every
  four_eyes-classified tool (whole-tool or per-action) is releasable by the
  headless worker (isSessionRequiredForRelease, newly exported) rather than
  stuck needing a live chat session — it passes as-is, since Phase 2's
  Google/M365 headless dispatch already covers every four_eyes member.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fourEyesTools was built only from the static TIER3_FOUR_EYES_TOOLS/
TIER3_FOUR_EYES_ACTIONS tables, so s1_isolate_device (moved to
TIER3_INPUT_AWARE_TOOLS) and manage_organizations (only covered by
accident via create_org) were structurally invisible to the
durable-executable contract even though they can resolve four_eyes at
runtime via resolveApprovalScope's input-aware overrides. Union in
TIER3_INPUT_AWARE_TOOLS and the tool-name projection of
TIER3_INPUT_AWARE_ACTIONS — a conservative superset is correct since
the assertion is about session-requirement, not resolved scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds intentSupervisedFourEyes.integration.test.ts, covering what the
existing decide-path integration suites don't: a four_eyes intent fans
out to both other eligible admins (never the requester), an approve at
t+30min still succeeds and releases inside the new 60-minute four_eyes
window (via direct DB timestamp manipulation, not a mocked clock), and
a disabled second admin correctly engages the four_eyes sole-operator
fallback instead of reclassifying to supervised.

approvalsDecideSupervised.integration.test.ts already covers the
supervised happy-path + revoked-permission; approvalsDecideAtomicity.
integration.test.ts already covers the fan-in fault-injection rollback.

Named in vitest.integration.config.ts's include list for discoverability
(the file already lives under src/__tests__/integration/, which both
that config's glob and vitest.config.ts's wholesale exclude already
cover automatically).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…four-eyes split

execute_command was reclassified `supervised` by an earlier task in this
plan (Task 1, aiGuardrails.ts), which collapses its approval fan-out to
exactly one requester-owned row. intentFanout.integration.test.ts and
intentSelfApproveGuard.integration.test.ts still seeded intents with
execute_command and asserted the pre-split multi-approver/four_eyes
sole-operator behavior, so they were either failing outright (wrong row
count/owner) or passing for the wrong reason (silently routed through the
unrelated supervised decide branch instead of the four_eyes one they exist
to cover). Switches every intent-creation call in both files to
restore_snapshot (four_eyes-classified), the same fix
approvalsDecideAtomicity.integration.test.ts already made for its own
fixture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Final whole-branch review turned up three IMPORTANT gaps:

1. The inline chat-session release path (aiAgentSdk.ts) won the
   approved->executing CAS and revalidated the requester's authorization,
   but never recomputed the pinned effect digest the way
   jobs/intentReleaseWorker.ts does — a four_eyes reference-approval whose
   target drifted during the approval window could execute stale content
   if it happened to complete inline instead of via the durable worker.
   Mirror the worker's computeEffectDigest recheck and CAS to
   failed:content_changed on mismatch.

2. Supervised approves polluted the four_eyes sole-operator audit signal:
   a supervised intent's sole approval row is always requester-owned, so
   every ordinary supervised approve was audited as
   self_approved_sole_operator (an outcome meant to flag four_eyes L3
   self-approval specifically). Gate soleOperatorApproval /
   details.soleOperator on approvalScope === 'four_eyes' in both
   routes/approvals.ts and services/actionIntents/intentService.ts;
   supervised approves now audit as approved with
   details.approvalMethod: 'supervised_self'.

3. The reaper's pending_approval predicate used bare
   approval_expires_at < now(), which is SQL NULL (never true) for legacy
   writer rows that never got the column backfilled — those rows could
   never be reaped even once their legacy expires_at deadline passed.
   Fall back to COALESCE(approval_expires_at, expires_at), mirroring the
   approved branch's existing construct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

Latest commit: ae5acdf
Status: ✅  Deploy successful!
Preview URL: https://8742f544.breeze-9te.pages.dev
Branch Preview URL: https://toddhebebrand-tier3-supervis.breeze-9te.pages.dev

View logs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant